<?php

function date_ts($date, $format) {
	$masks = array( 
	  'd' => '(?P<d>[0-9]{2})', 
	  'm' => '(?P<m>[0-9]{2})', 
	  'Y' => '(?P<Y>[0-9]{4})', 
	  'H' => '(?P<H>[0-9]{2})', 
	  'i' => '(?P<i>[0-9]{2})', 
	  's' => '(?P<s>[0-9]{2})', 
	 // usw.. 
	); 
	
	$datedef = array(
		'd' => 1,
		'm' => 1,
		'Y' => 0,
		'H' => 0,
		'i' => 0,
		's' => 0
	);
	
	$rexep = "#".strtr(preg_quote($format), $masks)."#"; 
	if(!preg_match($rexep, $date, $out)) {
		return false; 
	}
	$date = $out+$datedef;
	$ts = mktime($date['H'],$date['i'],$date['s'],intval($date['m']),intval($date['d']),intval($date['Y']));
	return $ts; 
}

abstract class ABModel {
	

	/**
	 * ----------------------------------
	 * METHODES A DEFINIR
	 * ----------------------------------
	 */
	
	/**
	 * Sauvegarde de l'objet courant
	 */
	public abstract function save();
	
	/**
	 * Chargement d'un objet d'id précis (au moins 1 attribut id, plusieurs supportés)
	 */
	public abstract function load($id);

	/**
	 * Supprime un élément d'id précis (au moins 1 attribut id, plusieurs supportés)
	 */
	public abstract function delete();
	
	/**
	 * Renvoie le schéma de la classe (tableau associatif 'attribut' => 'type')
	 * Les types possibles sont:
	 * - boolean
	 * - bool
	 * - int
	 * - integer
	 * - double
	 * - float
	 * - string
	 * 
	 * Le format peut être le suivant:
	   $schema = array(
		'attr1' => 'string(24)',
		'attr2' => 'int'
	   );
	 * OU
	   $schema = array(
		'attr1' => array('type' => 'string(24)'),
		'attr2' => array('type' => 'int')
	   );
	 *
	 * Le deuxième cas peut inclure les propriétés suivantes:
	 * - primarykey: true si c'est une clé primaire
	 * - type: le type (voir plus haut) Possibilité d'ajouter entre parenthèse la longueur (pour string)
	 * - sqlcolumn: le nom de la colonne SQL correspondante (si non identique à l'attribut)
	 */
	public abstract function schema();
	
	/**
	 * ----------------------------------
	 * FIN METHODES A DEFINIR
	 * ----------------------------------
	 */	

	/**
	 * Le schéma parsé et récupérable correctement
	 */
	protected $schema = array();
	
	protected static $db = null;
	
	/**
	 * Les options par défaut utilisé par la classe
	 * Contient les formats de date et datetime utilisé par défaut
	 * Le format utilisé est celui de date. Seul ces paramètres sont supportés
	 * - d, m, Y, H, i, s
	 *
	 * @link http://www.php.net/manual/fr/function.date.php
	 */
	public static $DEFAULT_OPTIONS = array(
		'date_format_db' => 'Y-m-d',
		'date_format_view' => 'd/m/Y',
		'datetime_format_db' => 'Y-m-d H:i:s',
		'datetime_format_view' => 'd/m/Y H:i:s'
	);
	
	/**
	 * Les noms des constantes correspondants aux valeurs par défaut des options
	 */
	private static $CONSTS_OPTIONS = array(
		'SIMPLEMODEL_DATE_FORMAT_DB' => 'date_format_db',
		'SIMPLEMODEL_DATE_FORMAT_VIEW' => 'date_format_view',
		'SIMPLEMODEL_DATETIME_FORMAT_DB' => 'datetime_format_db',
		'SIMPLEMODEL_DATETIME_FORMAT_VIEW' => 'datetime_format_view'
		
	);
	
	public function options() {
		return array();
	}
	
	
	/**
	 * Chargement d'un objet depuis un Statement.
	 */
	public function fromStatement($rowFetched) {
		$this->init($rowFetched, array('fromDB' => true));
	}
	
	/**
	 * Récupère le schéma déjà parsé, ou le parse si forcé ou si non parsé déjà
	 */
	public function getSchema($forcedParse = false, $className = "") {
		$this->parseSchema($forcedParse, $className = "");
		return $this->schema;
	}
	
	/**
	 * Obtient les clés primaires de la classe courante 
	 */
	public function getPrimaryKeys() {
		$a = array();
		$schema = $this->getSchema();
		foreach ($schema as $attrname => $props) {
			if ($props['primary']) {
				$a[$attrname] = $props;
			}
		}
		return $a;
	}
	
	/**
	 * Parse le schéma de la classe
	 */
	public function parseSchema($forced = false, $className = "") {

		if (!empty($this->schema) && !$forced) {
			return;
		}
		
		$user_schema = $this->schema();
		
		$schema = array();
		foreach ($user_schema as $attrname => $data) {
			$type = $data;
			$primary = false;
			$sqlcolumn = $attrname;
			$length = 0;
			$autoincrement = false;
			if (is_array($data)) {
				$primary = !empty($data['primarykey']);
				$type = $data['type'];
				if (!empty($data['sqlcolumn'])) {
					$sqlcolumn = $data['sqlcolumn'];
				}
				if (isset($data['autoincrement'])) {
					$autoincrement = $data['autoincrement'];
				}
			}
			if (strpos($type, '(') !== false) {
				$pattern = "/([a-zA-Z0-9_-]+)\([0-9+]\)/";
				$matches = array();
				if (preg_match($pattern, $type, $matches)) {
					$type = $matches[1];
					$length = intval($matches[2]);
				}
			}
			$schema[$attrname] = array(
				'primary' => $primary,
				'attribute_name' => $attrname,
				'sqlcolumn_name' => $sqlcolumn,
				'length' => $length,
				'type' => $type,
				'autoincrement' => $autoincrement
			);
		}
		$this->schema = $schema;
	}
	

	/**
	 * Option une option d'un tableau d'option, avec une valeur par défaut si non existant
	 */
	public static function getOption($options, $name, $default = null) {
		$v = $default;
		if (isset($options[$name])) {
			$v = $options[$name];
		}
		return $v;
	}
	
	/**
	 * Crée une requête INSERT
	 * Peut contenir plusieurs insertions
	 *
	 * Options obligatoires:
	 * - table: le nom de la table SQL
	 * - columns: les colonnes à insérer
	 * - values: l'ensemble des valeurs à insérer. Peut être un tableau de tableau de valeurs
	 *     * 1 insertion (id, name): array(1, "truc")
	 *     * Multiple insertions (id, name): array(array(1,"truc"), array(2,"truc 2"))
	 *     * 1 insertion avec tableau associatif (id, name): array('id' => 1, 'name' => 'truc')
	 *     * Multiple insertions avec tableau associatif (id, name): array(array('id' => 1, 'name' => 'truc'), array('id' => 2, 'name' => 'truc2'))
	 */
	public static function createInsertQuery($options = array()) {
		
		$table = self::getOption($options, "table", "");
		$columns = self::getOption($options, "columns", array());
		$values = self::getOption($options, "values", array());

		if (empty($values) || empty($columns) || empty($table)) {
			return null;
		}
		
		$multipleInsert = false;
		if (!is_array($values[0])) { // Mutiple Insert
			$values = array($values);
		}
		
		$query = "INSERT INTO $table ";
		$query .= "(".implode(',',$columns).")";
		$query .= " VALUES ";
		
		$first = true;
		$insertValues = array();
		foreach ($values as $lineValues) {
			$orderedLineValues = $lineValues;
			if (self::isAssocArray($lineValues)) {
				$orderedLineValues = array();
				foreach ($columns as $column) {
					$val = null;
					if (isset($lineValues[$column])) {
						$val = $lineValues[$column];
					}
					$orderedLineValues[] = $val;
				}
			}
			
			$insertValues[] = "(".implode(',', self::array_str_repeat("?", count($orderedLineValues))).")";
		}
		
		$query .= implode(',',$insertValues);
		
		$db = self::getDB();
		$st = $db->prepareAndExecute($query, $orderedLineValues);
		self::logQuery($query, $orderedLineValues);
		
		return $st;	
	}
	
	public static function logQuery($query, $values = array()) {
		
	}
	
	/**
	 * Crée une requête SQL, et renvoie le statement après son exécution
	 * Permet de créer des requêtes classiques INSERT, UPDATE et DELETE
	 * Pour les insert, voir la méthode createInsertQuery
	 *
	 * Options possible:
	 * - query: le début de la requête (SELECT + JOIN)
	 * - where: les conditions where (formatté pour createConditions, voir plus bas)
	 * - groupby: le group by a effectué
	 * - having: les conditions having (formatté pour createConditions; voir plus bas)
	 * - limit: le nombre de résultats
	 * - offset: pour commencer à une ligne précise
	 * - page: si renseigné à la place de offset, se met à la page X du nombre de résultat voulu. Commence à la page 1 (avec 20 en limit: 1 = 0, 2 = 20, 3 = 40, etc...)
	 * - values: les valeurs des autres conditions paramétrées qui ne sont ni dans where ni dans having
	 * - set: ensemble de tableaux (colonne, valeur [, type]), ou ensemble de chaîne 'colonne = X'
	 *
	 * @param $options les options pour créer la requête
	 * @param $optionsModel les options du Model (format de date principalement)
	 */
	public static function createQuery($options = array(), $optionsModel = array()) {
		$query = self::getOption($options, "query", "");
		$where = self::getOption($options, "where", array());
		$groupby = self::getOption($options, "groupby", "");
		$groupby = self::getOption($options, "orderby", "");
		$having = self::getOption($options, "having", array());
		$set = self::getOption($options, "set", array());
		$limit = self::getOption($options, "limit", 0);
		$offset = self::getOption($options, "offset", 0);
		$page = self::getOption($options, "page", 0);
		$values = self::getOption($options, "values", array());
		
		
		if (!empty($set)) {
			$query .= " SET ";
			$sets = $set;
			if (is_array($set[0])) {
				$sets = array();
				foreach ($set as $s) {
					$column = $s[0];
					$val = $s[1];
					if (count($s) == 3) {
						$val = self::getValueFromType($s[2], $val, $optionsModel);
					}
					$sets[] = "$column = ?";
					$values[] = $val;
				}
			}
			$query .= implode(',',$sets);
		}
		
		if (!empty($where) && is_array($where)) {
			$query .= " WHERE ".self::createConditions($where, $values, $optionsModel);
		}
		
		if (!empty($groupby)) {
			$query .= " GROUP BY $groupby";
		}
		
		if (!empty($having) && is_array($having)) {
			$query .= " HAVING ".self::createConditions($having, $values, $optionsModel);
		}
		
		// Page: spécial aide aux dev pour la pagination, au lieu de renseigner le offset, uniquement le numéro de page 1 à X et le nombre de résultats (limit)
		if (empty($offset) && !empty($page) && !empty($limit)) {
			$offset = $limit*($page-1);
		}
		
		if (!empty($groupby)) {
			$query .= " GROUP BY $orderby";
		}
		
		
		
		// Limit et offset: déclaration dépendante de chaque serveur (mysql LIMIT LIMIT,OFFSET, postgresql LIMIT LIMIT OFFSET OFFSET)
		$driver_name = self::getOption($options,"driver","");
		if (empty($driver_name)) {
			$db = self::getDB();
			$driver_name = $db->getAttribute(PDO::ATTR_DRIVER_NAME);
		}

		switch($driver_name) {
			case 'mysql':
			case 'pgsql':
				if (!empty($limit)) {
					$query .= " LIMIT $limit";
				}
				if (!empty($offset)) {
					$query .= " OFFSET $offset";
				}
				break;

			default:
				break;
		}
		
		$db = self::getDB();
		$st = $db->prepareAndExecute($query, $values);
		self::logQuery($query, $values);
		
		return $st;	
	}
	
	/**
	 * Crée les conditions WHERE / HAVING selon un ensemble de condition définit de la sorte:
	 * array($type, $condition, [$value, $type])
	 * - $type: 'AND' ou 'OR'
	 * - $condition: la condition SQL (paramétrée ou non)
	 * - $value: la / les valeurs pour une condition avec paramètres (?)
	 * - $type: le / les types des valeurs renseignés précédemment. A renseigner uniquement pour les date et datetime, qui nécessite d'être convertit au bon format indique 'date' 'datetime'
	 *
	 * Spécificité, prends en compte la condition paramètrée =suivante: array('AND', 'id IN ?', array(1,3,4,5,6))
	 *
	 * @param $conditions les conditions formattés comme ci dessus
	 * @param $values les valeurs à indiquer dans le statement, si ce sont des conditions paramétrées (out)
	 * @param $options les options Model (format de date principalement)
	 */
	public static function createConditions($conditions = array(), &$values = array(), $options = array()) {
		$where = "";
		$options = self::getStaticOptions($options);
		foreach ($conditions as $c) {
			
			$type = " AND ";
			$condition = $c;
			
			if (is_array($c) && count($c) >= 2) {
				$type = " ".$c[0]." ";
				$condition = $c[1];
				
				if (isset($c[2])) {
					$val = $c[2];
					// Cas d'un typage de la donnée, pour les date et datetime
					if (!empty($c[3])) {
						$typeVal = $c[3];
						if (!is_array($val)) {
							$val = self::getValueFromType($typeVal, $val, $options);
						} else {
							$i = 0;
							foreach ($val as &$v) {
								if (is_array($typeVal)) {
									$ttype = $typeVal[$i];
									if (!empty($ttype)) {
										$v = self::getValueFromType($ttype, $v, $options);
									}
								}
								else {
									$v = self::getValueFromType($typeVal, $v, $options);
								}
								$i++;
							}
						}
					}
					if (is_array($val)) { // Cas d'un IN ?
						$nb_in = array();
						foreach ($val as $v) {
							$nb_in[] = "?";
							$values[] = $v;
						}
						$condition = str_replace("IN ?", "IN (".implode(',',$nb_in).")", $condition);
					
					} else {
						$values[] = $val;
					}
				}
			}
			
			if (!empty($where)) {
				$where .= $type;
			}
			
			if (is_array($condition)) {
				$condition = "(".self::createConditions($condition, $values, $options).")";
			}
			$where .= $condition;
		}
		
		return $where;
	}
	
	/**
	 * Renvoie true si le tableau en paramètre est un tableau associatif, false sinon
	 */
	protected static function isAssocArray($a) {
		return (is_array($a) && (bool)count(array_filter(array_keys($a), 'is_string')));
	}
	
	/**
	 * A deporter ou a mieux faire
	 */
	public static function array_str_repeat($str, $times) {
		$vars = array();
		for ($i = 0; $i < $times; $i++) {
			$vars[] = $str;
		}
		return $vars;
	}
	
	/**
	 * Change les valezurs par défaut
	 * Si $overwrite vaut true, seules les options renseignés seront changés (sinon, toutes)
	 */
	public static function setDefaultOptions($options = array(), $overwrite = true) {
		if (!$overwrite) {
			self::$DEFAULT_OPTIONS = $options;
		} else {
			foreach ($options as $option_name => $value) {
				self::$DEFAULT_OPTIONS[$option_name] = $value;
			}
		}
	}
	
	public static function getConstsOptions() {
		$opts = array();
		foreach (self::$CONSTS_OPTIONS as $constname => $option_name) {
			if (defined($constname)) {
				$opts[$option_name] = constant($constname);
			}
		}
		return $opts;
	}
	
	
	/**
	 * Obtient les options par défaut, en prenant en compte les constantes (en 2ème priorité)
	 */
	public function getOptions($options = array()) {
		
		$thisOptions = $this->options();
		
		if (!is_array($options)) {
			$options = array();
		}
		if (!is_array($thisOptions)) {
			$thisOptions = array();
		}
		
		$opts = array_merge(self::getConstsOptions(), self::$DEFAULT_OPTIONS, $thisOptions, $options);
		return $opts;
	}
	
	/**
	 * Récupère les options en statique
	 */
	public static function getStaticOptions($options = array()) {
		
		if (!is_array($options)) {
			$options = array();
		}
		$opts = array_merge(self::getConstsOptions(), self::$DEFAULT_OPTIONS, $options);
		return $opts;
	}

	
	
	/**
	 * Fonction à utiliser dans le constructeur
	 * Permet d'initialiser un objet selon le type du paramètre:
	 * - array associative: initFromAssoc
	 * - PDOStatement: fromStatement (à définir)
	 * - EnhancedPDOStatement: fromStatement, récupération du lastFetch automatique
	 * - Autre !empty: load (à définir)
	 *
	 */
	public function init($param, $options = array()) {
		if (self::isAssocArray($param)) {
			$this->initFromAssoc($param, $options);
		}
		else if ($param instanceof EnhancedPDOStatement || $param instanceof PDOStatement) {
			$rowFetched = $options;
			if ($param instanceof EnhancedPDOStatement && empty($rowFetched)) {
				$rowFetched = $param->lastFetch;
			}
			$this->fromStatement($rowFetched, $param);
		}
		else if (!empty($param)) {
			// On prends tous les paramètres (load avec multiple ID)
			// Attention: les tableaux indexés non associatifs sont pris en compte dans le load
			$params = func_get_args();
			call_user_func_array(array($this, 'load'), $params);
		}
	}
	
	public function getType($attr, $value) {
		$schema = $this->getSchema();

		$type = 'auto';
		if (isset($schema[$attr])) {
			$type = $schema[$attr]['type'];
		}
		
		if ($type == 'auto') {
			$type = self::getDetectedType($value);
		}
		return $type;
	}
	
	/**
	 * Initialise l'objet courant depuis un tableau associatif
	 * Utilise le schéma pour forcer le type, sinon utilisation de getDetectedType (voir plus bas)
	 */
	public function initFromAssoc($a, $options = array()) {
		
		$schema = $this->getSchema();
		$optionsModel = $this->getOptions();
		
		foreach ($a as $name => $value) {
			if (!is_string($name)) {
				continue;
			}
			
			$type = $this->getType($name, $value);
			
			if (!empty($options['fromDB'])) {
				$value = self::getValueFromDB($type, $value, $optionsModel);
			} else {
				$optionsModel['convertDate'] = false;
				$value = self::getValueFromType($type, $value, $optionsModel);
			}
			
			$this->$name = $value;
		}
	}
	
	/**
	 * Transforme la valeur dans le type voulu
	 * Cas spéciaux:
	 * - boolean / bool pour une string 'true' /'false', transformera en true / false
	 * - boolean / bool pour une chaîne 'on': true
	 *
	 * - double / float avec des ',': transformera la ',' en '.'
	 *
	 * - string: ajout de la chaîne '' au type
	 * - string pour true / false: transformera en 'true' / 'false'
	 * 
	 * - date pour string: convertit la date au format 'user' au format 'db'
	 * - date pour int: convertit le timestamp au format 'db'
	 *
	 * - datetime pour string: convertit la date au format 'user' au format 'db'
	 * - datetime pour int: convertit le timestamp au format 'db'
	 *
	 * Pour int: utilisation de intval
	 * Pour double / float: utilisation de floatval
	 * Pour boolean: utilisation de !empty (si en dehors des cas spéciaux)
	 */
	protected static function getValueFromType($type, $value, $options = array()) {
		$v = $value;
		
		switch($type) {
			case 'double':
			case 'float':
				if (is_string($value) && strpos($value, ',') !== false) {
					$value = str_replace(',','.', $value);
				}
				$v = floatval($value);
				break;
				
			case 'string':
				if ($value === true) {
					$v = 'true';
				} else if ($value === false) {
					$v = 'false';
				} else if (!is_null($value)) {
					$v = $value.'';
				}
				break;
			
			case 'bool':
			case 'boolean':
				if (is_string($value)) {
					switch($value) {
						case 'true':
						case 'on':
							$v = true;
							break;
						case 'false':
							$v = false;
							break;
					}
				} else {
					$v = !empty($value);
				}
				break;
				
			case 'integer':
			case 'int':
				$v = intval($value);
				break;
				
			case 'date':
				if (!isset($options['convertDate']) || $options['convertDate']) {
					$timestamp = $value;
					if (is_string($value)) {
						$timestamp = date_ts($value, $options['date_format_view']);
					}
					$v = date($options['date_format_db'], $timestamp);
				}
				break;
				
			case 'datetime':
				if (!isset($options['convertDate']) || $options['convertDate']) {
					$timestamp = $value;
					if (is_string($value)) {
						$timestamp = date_ts($value, $options['datetime_format_view']);
					}
					$v = date($options['datetime_format_db'], $timestamp);
				}
				break;
		}
		return $v;
	}
	
	/**
	 * Renvoie la valeur obtenue de la DB, avec le bon type (forcé du schema ou non)
	 */
	public function getValueFromDB($type, $value, $options = array()) {
		$v = $value;
		
		switch($type) {
			case 'double':
			case 'float':
				$v = floatval($value);
				break;
				
			case 'string':
				if (!is_null($value)) {
					$v = $value.'';
				}
				break;
			
			case 'bool':
			case 'boolean':
				$v = !empty($value);
				break;
				
			case 'integer':
			case 'int':
				$v = intval($value);
				break;
				
			case 'date':
				$timestamp = $value;
				if (is_string($value)) {
					$timestamp = date_ts($value, $options['date_format_db']);
				}
				$v = date($options['date_format_view'], $timestamp);
				break;
				
			case 'datetime':
				$timestamp = $value;
				if (is_string($value)) {
					$timestamp = date_ts($value, $options['datetime_format_db']);
				}
				$v = date($options['datetime_format_view'], $timestamp);
				break;
		}
		return $v;
	}
	
	
	/** 
	 * Renvoie le type détectée automatiquement
	 * Cas spéciaux:
	 * - numerique avec un .: double
	 * - numerique (autre): integer
	 * - string 'true', 'false': booléen
	 */
	protected static function getDetectedType($value) {
		$type = 'string';
		if (is_numeric($value) && strpos($value, '.') !== false) {
			$type = 'double';
		}
		else if (is_numeric($value)) {
			$type = 'integer';
		}
		else if (is_string($value) && ($value == 'true' || $value == 'false')) {
			$type = 'boolean';
		}
		else {
			$type = gettype($value);
		}

		return $type;
	}

	/**
	 * Renvoie un nouvel objet Database
	 * Met l'attribut pdoDefaultCursor à la valeur PDO::CURSOR_SCROLL (nécessaire pour revenir en arrière)
	 */
	public static function getDB($options = array()) {
		$db = null;
		if (!empty($options['new'])) {
			$db = self::$db;
		}
		if (class_exists('Database') && is_null($db)) {
			$db = new Database($options);		
		}
		return $db;
	}

}